🤖 feat: back up Mux settings to a git repository - #3767
Conversation
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4b7a54c1d0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Thanks, three of the four were real. Fixed with red-green verified regression tests (removed each guard, confirmed the intended test fails, restored). 1. Windows separators in manifest paths ( 2. Managed paths deleting the repository cache ( 3. Bare 4. Native titles (
|
|
@codex review Please take another look. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e9a25a8260
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
All five addressed, each with a red-green verified regression test (removed the guard, confirmed the intended test fails, restored). 1. 2 & 3. Symlink traversal on read and restore — fixed. I confirmed the premise before fixing: git stores symlinks as mode 120000, so a backup repository can carry one, and Added one shared helper, 4. Validate payload contents before writing — fixed. 5. Non-fast-forward mapped to drift — fixed. Correct, and the cause was exactly as described: One note on how I tested it, since it changes the claim: I first tried to force the drift through the real adapter by moving the remote between pushes, and the push succeeded. That is not the bug you found, it is by design:
|
|
@codex review Please take another look. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e9a25a8260
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
CI's Root cause. void mock.module("@/version", () => ({ VERSION: "test-version" }));CI runs all unit files in one Bun process, so that module mock reaches this feature. This mattered beyond the test: any build whose version metadata is unavailable would have written a manifest that this code cannot read, i.e. an unrestorable backup. Fix. One correction to my own work. My first attempt also relaxed Local validation: 48 backup tests, 5 UI tests,
|
|
@codex review Please take another look. |
|
Both correct, and both were consequences of my own earlier fixes. Fixed. 1. Configured token never reached git ( 2. Override shortcut still gated on Local validation: 49 backup tests, 5 UI tests, Note on the
|
|
@codex review Please take another look. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5221090a2e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review Addressed all four findings from the previous round in 8b6b900 and 9700219, each with a red-green verified regression test:
Replies are on each thread and the threads are resolved.
|
|
@codex review One more preview correction pushed in fb41dd9: restore merges preferences into the local config rather than replacing a file, so preview now compares the merge result. A backup whose preference values all match locally no longer reports
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fb41dd907c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Two more fixes from a self-review pass, pushed in 8babbc6 and 0050d1b. Both are red-green verified. Restore was dropping machine-local preferences (data loss). Redaction and mode gaps. Bare One deliberate non-change, so it is on the record: a redaction marker makes the whole JSONC scalar locally owned, so a non-secret edit the backup made inside that same string is not restored. This is intentional. Splicing a local credential into backup-controlled text would let a tampered backup move that credential to a different host or binary. The policy is stated at the restore rule and pinned by a test. @codex review
|
|
@codex review Round addressed in c00444e (plus 8babbc6 and 0050d1b, pushed just before this review landed):
Every fix is red-green verified. Backup suite is 57 tests,
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c00444e125
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review Both findings confirmed and fixed in 6e60656, each red-green verified:
Backup suite is 58 tests,
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6e60656f6b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review All four findings confirmed and fixed in e667e5c, each red-green verified:
Backup suite is 60 tests, Separately, the earlier
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e667e5c173
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review All four confirmed and fixed in 8382ddb, each red-green verified:
Backup suite is 62 tests,
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8382ddbc66
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review All three confirmed and fixed in b74a6e3, each red-green verified:
Backup suite is 64 tests,
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b74a6e3888
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review Fixed in 2b94f45. This was a straight violation of the self-healing rule you cited: rehydration parsed the local The guarantee is pinned by "restores over a malformed local MCP config", red-green verified by removing the try/catch. I also wrote a test for the early return and then deleted it: with the try/catch in place that branch is behaviorally unobservable, so the test passed with the code removed. I would rather have no test than one that cannot fail. Backup suite is 65 tests,
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2b94f4549e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review Both confirmed and fixed in 64baf00, each red-green verified:
Backup suite is 67 tests,
|
--- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh -->
--- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh -->
…ify) Scoped to the Codex-fix commits plus the CI test-infra fixes. Notable: BackupSection no longer blocks its initial settings load on config subscription establishment; it refreshes again once subscribed so changes made during setup are still picked up (regression tests added).
Codex round 7: 1. Validate the remote managed tree before checkout: enumerate it offline via ls-tree (blob:none already fetched the trees), refuse trees over the file-count limit, size present blobs with lazy fetch disabled, prefetch missing blobs in small batches through the credential ladder, and enforce per-file and running-total limits between batches. Direct OID wants bypass --filter=blob:limit (verified empirically), so batching is the bound. The refusal is a recognized BackupInvalidPayloadError so a bloated repository does not trigger a cache rebuild loop. 2. The initial settings snapshot no longer marks settings fresh, so destructive actions stay disabled until a refresh runs with the config subscription armed (or subscription setup definitively fails). Closes the mount-time window where another window's repository change could enable Restore against a stale tuple.
…xity Codex round 7b: 1. Every networkGit invocation now defaults to a 16 MiB output cap (MAX_NETWORK_GIT_OUTPUT_BYTES): remotes, SSH transports, and credential helpers are untrusted input and could otherwise buffer unbounded diagnostics in the main process. lsRemote keeps its tighter 1 MiB cap, and overflow classifies as the existing remote-unreachable error. 2. Payload paths are bounded by MAX_BACKUP_PATH_DEPTH (24 components) and MAX_BACKUP_DIRECTORY_COUNT (4096 distinct directory prefixes), enforced during manifest parsing, publication, restore planning, local collection, and the pre-checkout managed-tree validation, so a manifest within the byte and file-count limits cannot encode millions of directory components for Preview to lstat or Restore to create. The local snapshot path keeps count and complexity checks only: snapshot names preserve current-filesystem forms that portable validation refuses, and collection already validated them under local rules.
Codex round 7c: --depth=1 alongside --filter=blob:none on both the cache clone and the branch fetch bounds unvalidated transfer to the tip commit (222-commit/2000-file fixture: 667 objects and 73 KiB down to 4 objects and 6 KiB). Blob prefetch adds --refetch, which a shallow partial clone requires to receive promised blobs by OID without deepening history. tree:0 was evaluated and rejected: offline ls-tree breaks and direct tree-OID refetch pulls the whole outside tree anyway, so the tip's tree objects remain the accepted residual.
…empty-dir bounds Codex round 8: 1. P1: a save response is a snapshot from before it was handled, so the handler no longer marks it fresh; freshness comes from re-reading the configuration after the save, closing the window where another window's concurrent save could leave Restore enabled against a stale repository. 2. Gitlinks under the managed path are refused before checkout as a recognized invalid payload: collection only emits regular files, so no legitimate backup contains submodules, and each mode-160000 entry would otherwise materialize an uncounted directory. 3. The cache object store is bounded (MAX_BACKUP_CACHE_OBJECT_KIB, 512 MiB) after clone, fetch, and each blob-prefetch batch via count-objects: git has no client-side received-bytes cap and tree filters break the offline ls-tree contract, so the subprocess timeout bounds transfer duration and this bounds retained object data. 4. Local collection charges each directory to the complexity budget before recursing, so broad empty-directory trees can no longer bypass MAX_BACKUP_DIRECTORY_COUNT, and directory paths get the same depth check as file paths.
|
@codex review |
94e42b8 to
edc1190
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: edc1190801
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…bounds Codex round 9: 1. When the config change stream dies after freshness was established, the section clears settingsFresh: a dead stream cannot report another window's repository change, so destructive actions stop trusting the loaded tuple while settings stay visible. 2. An object-store budget overflow now discards the cache before the error surfaces; retries start from a fresh clone instead of fetching into an arbitrarily oversized store. Other invalid-payload refusals keep their small, reusable caches. 3. Manifest MCP redaction metadata is bounded (256 redactions, 64 segments per path, 2048 cumulative) before Sets or serialized paths are built, so a near-cap manifest cannot expand into hundreds of MB of derived structures during preview.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4c0d5f8cb3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codex round 10: 1. When config subscription setup fails, the fallback read still renders the latest settings but never grants freshness: with no listener, this window cannot see another window's later repository change, so destructive actions stay disabled rather than trusting an unwatchable snapshot. 2. Inactive repository caches are reaped after each successful prepare: a process-global registry of active cache basenames protects in-flight operations, the current cache plus the two most recently used inactive caches are retained (quick A/B switching avoids re-clones), and older caches go through the crash-safe rename-to-tombstone path. mtime only orders candidates already proven inactive.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2979fd6ee1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…tree Codex round 11: 1. Freshness is now granted only while the config stream is live, decided inside refresh itself, so the post-save re-read (and any other refresh) cannot re-enable destructive actions after subscription setup failed or the stream died. 2. The post-materialize clean covers the whole cache worktree instead of only the current managed path: the cache identity is repository and branch, so exports written under a previously configured subdirectory would otherwise accumulate outside every later sparse checkout. The glob-literal test now pins removal of strays while sparse and staging pathspecs stay literal; the unsafe-managed-path test vehicle moved to stageAndCommit.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4955283e67
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
--- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh -->
Reap inactive backup caches even when repository preparation rejects, while preserving the failed cache and original error. Scope config-stream liveness to each API subscription so replacement clients cannot inherit or overwrite another generation's freshness. --- _Generated with `mux` • Model: `openai:gpt-5.6-sol` • Thinking: `xhigh`_ <!-- mux-attribution: model=openai:gpt-5.6-sol thinking=xhigh -->
|
@codex review |
|
Codex Review: Didn't find any major issues. Another round soon, please! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Summary
Adds phase 1 of settings backup: a Backup settings tab that pushes a portable subset of
~/.muxto a git repository the user specifies, and restores it on another deployment. Backup and restore are manual, the repository is addressed by URL rather than a new sign-in flow, and MCP commands and URLs remain usable on a fresh device while literal header values stay redacted.Background
Mux user state lives entirely under
MUX_ROOTon one host, so a second deployment or a fresh install starts from scratch. The obvious idea, "sign in with GitHub and use the existing login", does not work today:serverAuthService.ts:12-18)read:usercopilotOauthService.ts:11-19)read:usergithub-copilot.apiKeyGH_TOKENforwarding (credentialForwarding.ts:28-30)Neither token can clone or push, and there is no GitHub App or
reposcope anywhere in the repo. Storing an OAuth token or PAT for backups, even a read-only one, is out of the question, so this PR adds no token input, no token storage, and no token pathway of any kind. Mux never holds token material itself: authentication is delegated entirely to credentials that already live on the backend host.Implementation
Credential ladder (
credentials.ts). One controlled attempt, then an ambient retry. The controlled attempt clears inherited helpers (-c credential.helper=) so the mechanism that authenticated is known and reportable in the UI:-o BatchMode=yesappended to any ambientGIT_SSH_COMMANDso a passphrase prompt fails instead of hanging, without discarding a host's ssh wrapper.gh auth status --hostname <host>succeeds:-c credential.helper='!gh auth git-credential'. This is the honest way to reuse an existingghlogin, and the token never passes through Mux.GH_TOKEN,GITHUB_TOKEN, and their enterprise variants are stripped from both the probe and the helper invocation, sinceghwould otherwise consume them ahead of its stored login and quietly turn this rung back into a token pathway.Every rung sets
GIT_TERMINAL_PROMPT=0,GH_PROMPT_DISABLED=1,GCM_INTERACTIVE=never. Repository settings reject credentials in URL userinfo and high-confidence query or fragment parameters before the URL can reachconfig.jsonor cache git metadata.Dedicated secret stores have no export path. This is enforced structurally, not by filtering:
payload.tswalks an explicit path allowlist, soproviders.jsonc,secrets.json,mcp-oauth.json,server.lock, andserverAuthSessions.jsonhave no code path into the payload. A closed-world test seeds all of them into a fakeMUX_ROOTand asserts none appear in the pushed tree. Free-form documentation still passes through the high-confidence scanner. Every exported MCP command requires exact-payload approval, and MCP URLs receive structural checks for raw, malformed special-scheme, and WHATWG-normalized authority userinfo plus credential-like query or fragment parameters, including low-entropy values. Any override is bound to the exact payload bytes the user reviewed.What syncs:
AGENTS.md,agents/*.md,skills/**(execute bits preserved, so skill scripts stay runnable),memory/global/**,mcp.jsonc(commands and URLs verbatim, portable runtime fields, header names, and secret references; literal header values redacted), andpreferences.json. Preferences use an explicit versioned allowlist becauseAppConfigOnDiskSchemais.passthrough(), so a denylist would leak every future key; the same reasoning excludes the record-typedai.providerOptionsproviders, whose schema isz.record(z.unknown()). Project-path-keyed and workspace-keyed entries are excluded as machine-local, andAGENTS.local.mdon the strength of its own.localmarker.appearance.editorConfigis excluded too: itscustomCommandreachesspawn(..., { shell: true }), so a restore from a repository someone else can write to would be a code-execution vector. Symlinks are never collected, and neither is any hidden file: one rule applied to every path segment keeps.gitobject stores,.envand its variants, and.netrcout of the recursive collections, where the secret scanner would not recognise a low-entropy value likePASSWORD=hunter2anyway.MCP commands and URLs are portable data. Bare stdio command strings and object
commandandurlvalues are stored verbatim so a fresh device can reconstruct its servers. Every exported command, recognizable secret, and structurally credential-bearing URL component blocks push until the user approves the exact payload digest. Restore separately derives every runnable command it would introduce, change, or enable from the exact bytes it would write and refuses withCOMMAND_APPROVAL_REQUIREDuntil the user approves that text. A command shadowed by a non-empty URL needs no restore approval becauseMcpConfigService.normalizeEntry()ignores it.Redaction metadata disambiguates literal marker values. New manifests persist exact JSON paths for values the exporter redacted, including an explicit empty list when
mcp.jsonchas no redactions. Restore treats only those paths as placeholders, so an approved command whose literal text is__MUX_BACKUP_REDACTED__remains executable data and still goes through command approval. Metadata is validated against the final serialized MCP document, duplicate manifest and JSONC keys are rejected, and projection-dropped keys cannot leave stale paths behind.Unrecognized MCP fields remain local.
McpConfigService.normalizeEntry()readstransport,disabled,toolAllowlist,url,command, andheaders, and ignores the rest, so a field likeenv: { "API_KEY": "hunter2" }is a credential nobody would notice leaving. Export projects exactly the fields Mux reads, at the types it reads them as; every other value becomes a marker and restore uses only the local value at that exact path. Portable-field lookups require an own property, so document-controlled names such astoStringandconstructorcannot resolve inherited validators.Header credentials never come from the repository. Export replaces literal header values with a redaction marker. A portable
{ secret: NAME }reference may remain in the repository projection, but restore keeps a header only when the local file already has a value at that exact path and the restored entry still points at the same endpoint. A changed endpoint or a fresh device with no local header value gets no credential, so a backup cannot redirect a locally resolved secret.Repository exports strip MCP comments, while restore preserves local-only definitions. A comment is prose the projection cannot inspect, so export reserializes the projected values instead of publishing the user's text. Restore applies edits in place, keeps destination-only server definitions, and carries their raw JSONC comments and trivia with them. Same-name definitions from the backup remain authoritative.
Older marker-based backups remain safe. A manifest without redaction-path metadata is treated as a legacy backup, so marker-bearing values still rehydrate from local state when available. If no usable local value exists, restore removes the executable marker or unusable server instead of passing marker text to the runtime. Existing repositories remain readable without a schema bump, while their next write adds exact metadata and prevents marker ambiguity.
Only documentation publishes from a recursive collection without review.
skills/**andmemory/global/**hold whatever the user put there, and no content scanner can decide whether an arbitrary file is a credential, since{"password":"hunter2"}has no distinguishing shape. So the gate is structural: anything that is not.md,.mdx,.markdown, or.txt, plus any file whose path promises credentials or has the exact conventional stemauthorpasswd, is held back until the user approves that exact payload. The approval is a hash of every flagged path and its contents, so approving one payload cannot publish a different one another window wrote meanwhile.Everything lands in one managed subdirectory (default
mux/) so an existing dotfiles repo is a safe target, and a persistent cache clone under<MUX_ROOT>/backup-cache/makes the two-direction preview cheap. Both sides check sizes before reading, since a repository is written by anyone who can push to it and a payload is buffered whole. The clone is sparse, single-branch, and blob-filtered, so a dotfiles repo with substantial history elsewhere is neither materialized nor transferred, and a path this platform cannot create never blocks a backup. Local paths andfile:URLs use an upload-pack transport with filtering enabled for both clone and later fetches, preventing Git's local optimization from copying the full object database. Cache creation preserves the remote's SHA-1 or SHA-256 object format when the backup branch is missing, including zero-ref remotes, and SHA-256 clones retain their required extension during config rebuilding. Cache corruption cannot be enumerated, since an emptyHEAD, a truncated index, and a ref naming nothing all keep the shape a structural check looks for and only fail a later Git command. So any unrecognized failure discards the disposable cache and retries once rather than leaving Preview, Push, and Restore blocked. Refusals to touch content the cache cannot prove it owns are excluded from that rebuild, as are outages and origin mismatches, so neither a foreign path nor a healthy cache is deleted; the discard itself rechecks ownership at the point of deletion. Relative local repository paths are anchored to the stable parent of<MUX_ROOT>/backup-cache(MUX_ROOTitself), so terminal and desktop launches use the same repository andgit -Ccannot reinterpret the path from the cache. Before Git runs, Mux rejects symlinks and special files under.gitand replaces multiply-linked metadata with cache-owned inodes, so Git cannot rewrite an outside hard-link alias; new local clones also disable Git's hard-link optimization. The cache config is rebuilt from validated platform flags and known forced values on every use, so malformed retained settings cannot brick later retries. The payload is treated as bytes: end-of-line conversion is pinned off in the cache, because the manifest checksums what was written and a restore writes what it reads.Local payload work is serialized across repositories. The repository lock is keyed per repository and branch, but every payload adapter reads and writes the one Mux root, so a second lock on a single key wraps the local payload work and is always taken inside the repository lock. Without it a push could export a half-restored root and publish it as a complete backup. Git and network work stays outside it, so different repositories still run in parallel. Serialization is process-local; cross-process locking is out of scope.
Validation
Beyond unit tests, a service-level integration test drives a real bare repository end to end and asserts the security invariant on bytes that actually reached a remote. Dogfooded all 12 planned scenarios against a live dev server and a real bare repo, which found 8 defects that are fixed here:
Defects found and fixed during validation
BackupServicedeclared its two dependency interfaces but nothing implemented them, so the service container did not compile.manifest.exportedAt, so an unchanged backup produced a commit that only churned metadata.preferences.jsonas added, because preferences live in config and have no local file to compare against.createBackupPayloadthrew on detected secrets before the service could apply the user's override, making the override unreachable.isPortableReferencetreated$VAR/${VAR}/env:VARheader strings as portable and exported them verbatim, butMCPHeaderValueisstring | { secret }, so Mux never interpolates those and they are literal credentials.mcp.jsoncthroughJSON.stringify, discarding the document the user had just previewed. Restore now appliesjsoncedits in place. (The export deliberately does reserialize, so that no comment ships.)ENOENTcontaining the internal cache path.Each fix has regression coverage. The current MCP portability work covers verbatim commands and URLs, literal-header redaction, exact-payload publish approval for every command, exact runnable-command restore approval, literal redaction-marker commands, strict persisted metadata validation, metadata-less legacy backups, prototype-colliding portable-field names, projection-dropped
__proto__keys, disabled URL-to-stdio transitions, local-only server preservation, malformed map rejection, JSONC comment retention, low-entropy and malformed special-scheme URL credential gating, conventional credential filename stems, special-file-safe local reads, and a real bare-repository push. Repository URL tests cover credential-bearing userinfo, query, and fragment forms while retaining benign parameters.make static-checkpasses, and the configured Storybook story was verified at 1200 px and 390 px with no horizontal overflow.Risks
Restore mutates user config, but it is deliberately not a mirror: it overwrites paths present in the backup, preserves local-only files and MCP servers, and writes a safety snapshot to
<MUX_ROOT>/backup-cache/restore-<id>/first. Restore planning rejects existing directories and special files before any destination is opened for writing. Optional local MCP reads preflight special files and use a nonblocking checked open so a path replacement race fails instead of hanging restore. Commands and URLs are repository-controlled input, so every exported MCP command and credential-bearing URL component requires exact-payload approval before publication, and changed runnable commands require separate exact-text approval before restore. Literal header values are never exported, and a local header is restored only to its original endpoint. Pushes use--force-with-leaseagainst the commit the preview read, so a remote that moved is reported as drift rather than overwritten, and a server-side denial such as a protected branch is reported as itself.Blast radius outside the feature is small: the shared-file edits are the settings tab registration, an added
settingsBackupconfig key, and the service container wiring.Unit-test infrastructure (now on main)
CI's
Test / Unitjob began failing repo-wide when runner-side test file ordering shifted and exposed latent cross-file state leaks inbun test. Those fixes landed on main via #3817 (including the SshPromptDialog mock restore and inline TaskGroupListItem menu stub this branch pioneered), so after rebasing onto that merge this PR no longer carries any shared test-infrastructure changes.Out of scope
Continuous/automatic sync, drift indicators, three-way merge, plans and project-scoped memory, and mirror-mode restore. A "sign in with GitHub" flow is rejected, not deferred: it would require Mux to hold an OAuth token, and storing one, even read-only, is out of the question.
Generated with
mux• Model:anthropic:claude-fable-5• Thinking:xhigh